package main

import (
	"archive/tar"
	"bufio"
	"bytes"
	"compress/gzip"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"strings"
	"text/template"

	"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
	"github.com/fleetdm/fleet/v4/pkg/download"
	"github.com/fleetdm/fleet/v4/server/fleet"
)

var (
	rxOption       = regexp.MustCompile(`\-\-(\w+)\s`)
	osqueryVersion = "5.19.0"

	structTpl = template.Must(template.New("struct").Funcs(template.FuncMap{
		"camelCase": camelCaseOptionName,
	}).Parse(`// Automatically generated by tools/osquery-agent-options for osquery {{ .OsqueryVersion }}. DO NOT EDIT!
// To update flags for a new osquery version, update the osqueryVersion variable in
// "tools/osquery-agent-options/main.go" and run "cd server/fleet/ && go generate".
package fleet

type osqueryOptions struct { {{ range $name, $type := .Options }}
	{{camelCase $name}} {{$type}} ` + "`json:\"{{$name}}\"`" + `{{end}}

	// embed the os-specific structs
	OsqueryCommandLineFlagsLinux
	OsqueryCommandLineFlagsWindows
	OsqueryCommandLineFlagsMacOS
	OsqueryCommandLineFlagsHidden
}

type osqueryCommandLineFlags struct { {{ range $name, $type := .Flags }}
	{{camelCase $name}} {{$type}} ` + "`json:\"{{$name}}\"`" + `{{end}}

	// embed the os-specific structs
	OsqueryCommandLineFlagsLinux
	OsqueryCommandLineFlagsWindows
	OsqueryCommandLineFlagsMacOS
	OsqueryCommandLineFlagsHidden
}
`))
)

type templateData struct {
	OsqueryVersion string
	Options        map[string]string
	Flags          map[string]string
}

func main() {
	fmt.Printf("Generating osquery flags for version: %s\n", osqueryVersion)
	if runtime.GOOS != "darwin" {
		log.Fatal("Currently only supported on macOS")
	}
	urlStr := fmt.Sprintf("https://updates.fleetdm.com/targets/osqueryd/macos-app/%s/osqueryd.app.tar.gz", osqueryVersion)
	osqueryTUFURL, err := url.Parse(urlStr)
	if err != nil {
		log.Fatalf("parse osquery TUF URL: %q: %s", urlStr, err)
	}
	tmpDir, err := os.MkdirTemp("", "")
	if err != nil {
		log.Fatalf("create temp dir: %s", err)
	}
	defer os.RemoveAll(tmpDir)
	osquerydAppTarGzPath := filepath.Join(tmpDir, "osqueryd.app.tar.gz")
	if err := download.Download(http.DefaultClient, osqueryTUFURL, osquerydAppTarGzPath); err != nil {
		log.Fatalf("download osqueryd.app.tar.gz to %s: %s", osquerydAppTarGzPath, err) //nolint:gocritic // ignore exitAfterDefer
	}
	if err := extractTarGz(osquerydAppTarGzPath); err != nil {
		log.Fatalf("extract tar.gz %q: %s", osquerydAppTarGzPath, err)
	}
	osquerydPath := filepath.Join(filepath.Dir(osquerydAppTarGzPath), "osquery.app", "Contents", "MacOS", "osqueryd")

	// marshal/unmarshal the OS-specific structs into a map so we have all their
	// keys and we can ignore them in the auto-generated structs (because we
	// can't auto- generate those, we'd only see the ones that exist on the
	// current OS)
	var allOSSpecific struct {
		fleet.OsqueryCommandLineFlagsLinux
		fleet.OsqueryCommandLineFlagsWindows
		fleet.OsqueryCommandLineFlagsMacOS
		fleet.OsqueryCommandLineFlagsHidden
	}
	b, err := json.Marshal(allOSSpecific)
	if err != nil {
		log.Fatalf("failed to marshal os-specific structs: %v", err)
	}

	var osSpecificNames map[string]interface{}
	if err := json.Unmarshal(b, &osSpecificNames); err != nil {
		log.Fatalf("failed to unmarshal os-specific structs to get the list of keys: %v", err)
	}

	// get the list of flags that are valid as configuration options
	b, err = exec.Command(osquerydPath, "--help").Output()
	if err != nil {
		log.Fatalf("failed to run osqueryd --help: %v", err)
	}

	var optionsStarted, optionsSeen, optionsDone bool
	var optionNames, allNames []string

	s := bufio.NewScanner(bytes.NewReader(b))
	for s.Scan() {
		line := s.Text()

		if !optionsStarted {
			if strings.Contains(line, "osquery configuration options") {
				optionsStarted = true
				continue
			}
		}

		if line == "" {
			if optionsSeen {
				// we're done for options, empty line after an option has been seen
				optionsDone = true
			}
			continue
		}

		matches := rxOption.FindStringSubmatch(line)
		if matches == nil {
			continue
		}

		if optionsStarted && !optionsDone {
			optionsSeen = true
			optionNames = append(optionNames, matches[1])
		}
		allNames = append(allNames, matches[1])
	}
	if err := s.Err(); err != nil {
		log.Fatalf("failed to read osqueryd --help output: %v", err)
	}

	// find the data type for each option
	var optionTypes []struct {
		Name string
		Type string
	}
	b, err = exec.Command(osquerydPath, "-S", "--json", "SELECT name, type FROM osquery_flags").Output()
	if err != nil {
		log.Fatalf("failed to run osqueryi query: %v", err)
	}
	if err := json.Unmarshal(b, &optionTypes); err != nil {
		log.Fatalf("failed to unmarshal osqueryi query output: %v", err)
	}

	// index the results by name
	allOptions := make(map[string]string, len(optionTypes))
	for _, nt := range optionTypes {
		allOptions[nt.Name] = nt.Type
	}

	// identify the valid config options
	validOptions := make(map[string]string, len(optionNames))
	for _, nm := range optionNames {
		// ignore the os-specific options
		if _, ok := osSpecificNames[nm]; ok {
			continue
		}

		ot, ok := allOptions[nm]
		if ok {
			validOptions[nm] = ot
		}
	}
	// identify the valid command-line flags
	validFlags := make(map[string]string, len(allNames))
	for _, nm := range allNames {
		// ignore the os-specific options
		if _, ok := osSpecificNames[nm]; ok {
			continue
		}

		ot, ok := allOptions[nm]
		if ok {
			validFlags[nm] = ot
		}
	}

	outputFilePath := os.Args[1]
	outputFile, err := os.OpenFile(outputFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0o644) // nolint:gosec // G302
	if err != nil {
		log.Fatalf("open output file %q: %s", outputFilePath, err)
	}
	defer outputFile.Close()

	if err := structTpl.Execute(outputFile, templateData{
		OsqueryVersion: osqueryVersion,
		Options:        validOptions,
		Flags:          validFlags,
	}); err != nil {
		log.Fatalf("failed to execute template: %v", err)
	}

	if err := outputFile.Close(); err != nil {
		log.Fatalf("close file %q: %s", outputFilePath, err)
	}
}

func camelCaseOptionName(s string) string {
	parts := strings.Split(s, "_")
	for i, p := range parts {
		parts[i] = strings.Title(p)
	}
	return strings.Join(parts, "")
}

// sanitizeArchivePath sanitizes the archive file pathing from "G305: Zip Slip vulnerability"
func sanitizeArchivePath(d, t string) (string, error) {
	v := filepath.Join(d, t)
	if strings.HasPrefix(v, filepath.Clean(d)) {
		return v, nil
	}

	return "", fmt.Errorf("%s: %s", "content filepath is tainted", t)
}

// extractTagGz extracts the contents of the provided tar.gz file.
func extractTarGz(path string) error {
	tarGzFile, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("open %q: %w", path, err)
	}
	defer tarGzFile.Close()

	gzipReader, err := gzip.NewReader(tarGzFile)
	if err != nil {
		return fmt.Errorf("gzip reader %q: %w", path, err)
	}
	defer gzipReader.Close()

	tarReader := tar.NewReader(gzipReader)
	for {
		header, err := tarReader.Next()
		switch {
		case err == nil:
			// OK
		case errors.Is(err, io.EOF):
			return nil
		default:
			return fmt.Errorf("tar reader %q: %w", path, err)
		}

		// Prevent zip-slip attack.
		if strings.Contains(header.Name, "..") {
			return fmt.Errorf("invalid path in tar.gz: %q", header.Name)
		}

		targetPath, err := sanitizeArchivePath(filepath.Dir(path), header.Name)
		if err != nil {
			return fmt.Errorf("sanitize failed: %s", err)
		}

		switch header.Typeflag {
		case tar.TypeDir:
			if err := os.MkdirAll(targetPath, constant.DefaultDirMode); err != nil {
				return fmt.Errorf("mkdir %q: %w", header.Name, err)
			}
		case tar.TypeReg:
			err := func() error {
				outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, header.FileInfo().Mode())
				if err != nil {
					return fmt.Errorf("failed to create %q: %w", header.Name, err)
				}
				defer outFile.Close()

				// Ignoring G110 because we are using this on tooling.
				if _, err := io.Copy(outFile, tarReader); err != nil { //nolint:gosec
					return fmt.Errorf("failed to copy %q: %w", header.Name, err)
				}
				return nil
			}()
			if err != nil {
				return err
			}
		default:
			return fmt.Errorf("unknown flag type %q: %d", header.Name, header.Typeflag)
		}
	}
}
